昨天提到新增項目時,使用者只能輸入兩個欄位,為了避免程式閃退,我們在 addItem 方法中使用了預設值。然而,這並不是最佳解決方案。因此,我們將讓使用者自行輸入所有必要的欄位,並設計一個新的頁面來完成這項功能。
我們預計在首頁的下方新增一個圓形的懸浮按鈕,上面顯示「+」符號,提示使用者可以點擊來新增項目。點擊按鈕後,畫面將切換到新增頁面 AddItemView,讓使用者自行輸入所需的資料欄位。
使用者填寫完資料後,點擊頁面下方的「新增物品」按鈕時,系統會先進行資料驗證。如果發現輸入的資料格式或內容有誤,系統會顯示錯誤提示,提醒使用者進行修正。若資料驗證通過並成功新增項目,系統也會顯示成功提示,並清空已填入的資料,以便使用者繼續操作。
我們將從設計 AddItemView 開始。這個頁面會使用多個 TextField
與 Stepper
,讓使用者輸入項目的名稱、數量、價格等資訊,這樣可以讓所有必要的資料在新增項目時都能被正確地填寫。
新增一個 Swift 檔,名稱為 AddItemView。建立完成後,加入以下程式碼:
import SwiftUI
struct AddItemView: View {
@State private var name: String = ""
@State private var quantity: Int = 1
@State private var price: String = ""
@State private var dateAdded: Date = Date()
@State private var expiryDate: Date = Date()
@State private var shouldRemindExpiryDate = false
var body: some View {
Form {
Section(header: Text("基本資料")) {
TextField("物品名稱", text: $name)
Stepper(value: $quantity, in: 1...100) {
Text("數量: \(quantity)")
}
TextField("價格", text: $price)
.keyboardType(.decimalPad)
}
Section(header: Text("日期")) {
DatePicker("加入日期", selection: $dateAdded, displayedComponents: .date)
Toggle("提醒到期日", isOn: $shouldRemindExpiryDate)
if shouldRemindExpiryDate {
DatePicker("到期日", selection: $expiryDate, displayedComponents: .date)
}
}
Button(action: {
// 新增物品的邏輯將在這裡加入
}) {
Text("新增物品")
.frame(maxWidth: .infinity)
}
}
}
}
這段程式碼出現一些之前沒介紹過的元件,讓我們一個一個來看吧!
Form
是 SwiftUI 中用來構建表單畫面的容器元件。表單通常用來組織和排列多個輸入元件,例如 TextField
、Toggle
、Picker
等。使用 Form
可以讓畫面變得更加整齊和易於操作,特別是在需要收集多個使用者輸入時非常實用。
參考資料:
Section
是用來在 Form
中組織內容的元件。它允許我們將表單內容分組,每個分組可以有一個標題(header)或尾部(footer)。這樣的分組方式能夠讓表單結構更加清晰,讓使用者更容易理解和填寫表單內容。
參考資料:
DatePicker
是 SwiftUI 中用來選擇日期或時間的元件。它可以用來讓使用者選擇日期、時間或兩者的組合。你可以透過 displayedComponents
來指定顯示的類型(如日期或時間)。在這個範例中,我們用 DatePicker
來選擇物品的加入日期和到期日。
參考資料:
Toggle
是一個開關元件,和 UIKit 中的 UISwitch
類似。Toggle
用來讓使用者開啟或關閉某個選項。在這個範例中,Toggle
用來決定是否顯示到期日提醒的 DatePicker
。
參考資料:
為了讓使用者輸入的資訊更全面,我們需要更新 DataManager 中的 addItem 方法,將預設值移除,改為使用傳遞進來的參數。除此之外,我們的目標之一是要在資料新增成功時提示使用者,因此也需要更新 saveContext
方法,使其在成功保存資料時回傳布林值,方便後續操作。
func addItem(name: String, quantity: Int, price: Double, dateAdded: Date, expiryDate: Date?) -> Bool {
let newItem = Item(context: container.viewContext)
newItem.id = UUID()
newItem.name = name
newItem.quantity = Int16(quantity)
newItem.isUsedUp = false
newItem.dateAdded = dateAdded
newItem.price = price
newItem.usedQuantity = 0
newItem.expiryDate = expiryDate
return saveContext()
}
private func saveContext() -> Bool {
let context = container.viewContext
if context.hasChanges {
do {
try context.save()
return true
} catch {
print("Failed to save context: \(error)")
return false
}
}
return true
}
接下來,我們要為 AddItemView 設計一個 ViewModel。這個 ViewModel 將負責處理使用者輸入的資料並與 DataManager 進行互動。
在 AddItemViewModel 中,我們會檢查使用者輸入的每個欄位,確保資料格式正確。如果有任何錯誤,將顯示錯誤訊息提醒使用者。如果所有檢查都通過並且資料成功加入資料庫,則會顯示成功訊息給使用者,同時清空已填入的欄位,讓使用者能方便地輸入下一筆資料。
新增一個 Swift 檔,命名為 AddItemViewModel,先將我們會需要的欄位都建立出來:
class AddItemViewModel: ObservableObject {
@Published var name: String = ""
@Published var quantity: Int = 1
@Published var price: String = ""
@Published var dateAdded: Date = Date()
@Published var expiryDate: Date = Date()
@Published var shouldRemindExpiryDate = false
@Published var showSuccessToast: Bool = false
@Published var failHandle: (isFail: Bool, title: String) = (isFail: false, title: "")
private let dataManager: DataManager
// 設定資料庫限制的最大值(這裡假設了一些值,根據實際情況進行調整)
private let maxNameLength = 255
private let maxQuantity: Int = 1000
private let maxPrice: Double = 1000000.0
init(dataManager: DataManager = DataManager()) {
self.dataManager = dataManager
}
}
除了我們需要儲存的資料以外,多宣告了 maxNameLength
、maxQuantity
、maxPrice
,是為了避免使用者輸入的數字超過資料庫限制,不過這裡我先隨意抓一個數字。
接著,我們建立 func reset() ,用於新增成功後,清空畫面上的資料,方便使用者加入下一筆資料使用。
func reset() {
name = ""
price = ""
quantity = 1
dateAdded = Date()
shouldRemindExpiryDate = false
expiryDate = Date()
failHandle = (isFail: false, title: "")
}
完成之後,重頭戲來了!我們要建立 func save(),來幫我們儲存使用者所輸入的資訊,並且在裡面做所有欄位的格式驗證。
func save() {
if validateAndSave(), let priceValue = Double(price) {
// 資料驗證通過,儲存資料
let result = dataManager.addItem(
name: name,
quantity: quantity,
price: priceValue,
dateAdded: dateAdded,
expiryDate: shouldRemindExpiryDate ? expiryDate : nil
)
if result {
showSuccessToast = true
} else {
failHandle = (isFail: true, title: "資料新增失敗")
}
}
}
func validateAndSave() -> Bool {
// 驗證名稱
guard !name.isEmpty else {
failHandle = (isFail: true, title: "名稱不能為空")
return false
}
guard name.count <= maxNameLength else {
failHandle = (isFail: true, title: "名稱字數不能超過 \(maxNameLength) 個字")
return false
}
// 驗證數量
guard quantity > 0 else {
failHandle = (isFail: true, title: "數量不能小於 1")
return false
}
guard quantity <= maxQuantity else {
failHandle = (isFail: true, title: "數量不能超過 \(maxQuantity)")
return false
}
// 驗證價格
guard let priceValue = Double(price), priceValue >= 0 else {
failHandle = (isFail: true, title: "價格格式錯誤或價格不能為負數")
return false
}
guard priceValue <= maxPrice else {
failHandle = (isFail: true, title: "價格不能超過 \(maxPrice)")
return false
}
// 驗證新增日
guard dateAdded <= Date() else {
failHandle = (isFail: true, title: "新增日期不能大於今天")
return false
}
// 驗證到期日(可為空,但若不為空則需驗證)
if shouldRemindExpiryDate && expiryDate < Date() {
failHandle = (isFail: true, title: "到期日不能小於今天")
return false
}
return true
}
這段程式碼中的驗證邏輯如下:
這樣的設計能夠確保使用者輸入的資料格式正確,並且在所有資料正確無誤後,才會將資料存入資料庫中。
這樣 AddItemViewModel 就完成了,下面放上完整程式碼:
class AddItemViewModel: ObservableObject {
@Published var name: String = ""
@Published var quantity: Int = 1
@Published var price: String = ""
@Published var dateAdded: Date = Date()
@Published var expiryDate: Date = Date()
@Published var shouldRemindExpiryDate = false
@Published var showSuccessToast: Bool = false
@Published var failHandle: (isFail: Bool, title: String) = (isFail: false, title: "")
private let dataManager: DataManager
// 設定資料庫限制的最大值(這裡假設了一些值,根據實際情況進行調整)
private let maxNameLength = 255
private let maxQuantity: Int = 1000
private let maxPrice: Double = 1000000.0
init(dataManager: DataManager = DataManager()) {
self.dataManager = dataManager
}
func reset() {
name = ""
price = ""
quantity = 1
dateAdded = Date()
shouldRemindExpiryDate = false
expiryDate = Date()
failHandle = (isFail: false, title: "")
}
func save() {
if validateAndSave(), let priceValue = Double(price) {
// 資料驗證通過,儲存資料
let result = dataManager.addItem(
name: name,
quantity: quantity,
price: priceValue,
dateAdded: dateAdded,
expiryDate: shouldRemindExpiryDate ? expiryDate : nil
)
if result {
showSuccessToast = true
} else {
failHandle = (isFail: true, title: "資料新增失敗")
}
}
}
func validateAndSave() -> Bool {
// 驗證名稱
guard !name.isEmpty else {
failHandle = (isFail: true, title: "名稱不能為空")
return false
}
guard name.count <= maxNameLength else {
failHandle = (isFail: true, title: "名稱字數不能超過 \(maxNameLength) 個字")
return false
}
// 驗證數量
guard quantity > 0 else {
failHandle = (isFail: true, title: "數量不能小於 1")
return false
}
guard quantity <= maxQuantity else {
failHandle = (isFail: true, title: "數量不能超過 \(maxQuantity)")
return false
}
// 驗證價格
guard let priceValue = Double(price), priceValue >= 0 else {
failHandle = (isFail: true, title: "價格格式錯誤或價格不能為負數")
return false
}
guard priceValue <= maxPrice else {
failHandle = (isFail: true, title: "價格不能超過 \(maxPrice)")
return false
}
// 驗證新增日
guard dateAdded <= Date() else {
failHandle = (isFail: true, title: "新增日期不能大於今天")
return false
}
// 驗證到期日(可為空,但若不為空則需驗證)
if shouldRemindExpiryDate && expiryDate < Date() {
failHandle = (isFail: true, title: "到期日不能小於今天")
return false
}
return true
}
}
有了 AddItemViewModel 後,我們需要更新 AddItemView,讓它可以與 ViewModel 進行資料綁定。
在 AddItemViewModel 中,當我們驗證出使用者輸入的欄位有錯誤時,需要提示使用者進行修改。為了讓提示更加美觀,我們將使用 AlertToast 來顯示錯誤提醒的訊息。
AlertToast
是一個 SwiftUI 中非常方便的第三方元件,用來顯示彈出式提示(Toast
)訊息。這個元件能夠在使用者操作後,快速且簡潔地給出回饋,例如成功提示、錯誤訊息等,無需額外的視窗或跳轉頁面。
在 AddItemView 中,我們會使用 AlertToast
來顯示驗證錯誤的訊息,讓使用者即時了解輸入的資料是否有誤。這樣的即時回饋有助於提升使用者體驗,避免因錯誤資料而導致後續問題。
如何使用 AlertToast
要在 SwiftUI 中使用 AlertToast
,首先需要引入這個第三方工具。你可以通過 Swift Package Manager 或 CocoaPods 來安裝它。詳細的安裝方法可以參考下方的參考資料。
接著,我們可以在 View 中利用 AlertToast
的方法來彈出提示訊息。
例如,在新增項目時,如果檢查到使用者輸入的資料有誤,我們就會調用 AlertToast
來顯示對應的錯誤訊息。這樣使用者可以在畫面中看到即時的錯誤提示,然後修正資料。
參考資料:
安裝完之後,讓我們來修改 AddItemView :
import SwiftUI
import AlertToast
struct AddItemView: View {
@ObservedObject var viewModel: AddItemViewModel
init(viewModel: AddItemViewModel = AddItemViewModel()) {
_viewModel = ObservedObject(wrappedValue: viewModel)
}
var body: some View {
VStack {
Form {
Section(header: Text("基本資料")) {
TextField("物品名稱", text: $viewModel.name)
Stepper(value: $viewModel.quantity, in: 1...100) {
Text("數量: \(viewModel.quantity)")
}
TextField("價格", text: $viewModel.price)
.keyboardType(.decimalPad)
}
Section(header: Text("日期")) {
DatePicker("加入日期", selection: $viewModel.dateAdded, displayedComponents: .date)
Toggle("提醒到期日", isOn: $viewModel.shouldRemindExpiryDate)
if viewModel.shouldRemindExpiryDate {
DatePicker("到期日", selection: $viewModel.expiryDate, displayedComponents: .date)
}
}
Button(action: {
viewModel.save()
}) {
Text("新增物品")
.frame(maxWidth: .infinity)
}
}
.toast(isPresenting: $viewModel.showSuccessToast, alert: {
AlertToast(type: .complete(Color.green), title: "完成")
}, completion: {
viewModel.reset()
})
.toast(isPresenting: $viewModel.failHandle.isFail, alert: {
AlertToast(type: .error(Color.red), title: viewModel.failHandle.title)
})
.navigationBarTitle("新增物品", displayMode: .inline)
}
}
}
我們將原本宣告的多個 @State 變數移除,改用 @ObservedObject 宣告 ViewModel。接著,將 TextField、Stepper、DatePicker 以及 Toggle 等元件的綁定參數替換為 ViewModel 內的對應屬性。Button 的 action 則設定為呼叫 ViewModel 的 save 方法。最後,我們加入了用來提示使用者的 AlertToast,並將 isPresenting 綁定到 ViewModel 的資料,以判斷是否需要顯示提示訊息。
到這裡為止,我們已經完成了新增項目的新頁面設計。接下來,我們需要在首頁新增一個按鈕,讓使用者可以點擊後跳轉到這個新增項目的頁面。
現在我們要在首頁(ContentView)下方新增一個懸浮的圓形按鈕,當使用者點擊這個按鈕時,會跳轉到剛剛設計的新增項目頁面(AddItemView)。
懸浮按鈕的設計將使用 SwiftUI 中的 ZStack 元件來實現,這樣可以讓按鈕懸浮在列表之上,即使資料量過多,列表可以向下滾動,按鈕仍然會固定在螢幕底部。關於 ZStack 的說明在 Day4,沒看過的讀者可以回去看一下唷!
首先,我們需要在 ContentView 中進行修改,來加入這個懸浮按鈕。
import SwiftUI
struct ContentView: View {
@StateObject private var viewModel: ItemViewModel = ItemViewModel()
@State private var navigateToAddItemView = false
var body: some View {
NavigationView {
ZStack(alignment: .bottomTrailing) {
List {
ForEach(viewModel.items) { item in
HStack {
Text(item.name)
Spacer()
Text("數量: \(item.quantity)")
}
}
.onDelete(perform: viewModel.deleteItems)
}
.onAppear {
viewModel.fetchItems()
}
.navigationTitle("家用品清單")
NavigationLink(destination: AddItemView(viewModel: AddItemViewModel()), isActive: $navigateToAddItemView
) {
Button(action: {
navigateToAddItemView = true
}) {
Image(systemName: "plus")
.font(.largeTitle)
.foregroundColor(.white)
.padding()
.background(Color.blue)
.clipShape(Circle())
.shadow(radius: 10)
}
.padding()
}
}
}
}
}
NavigationLink
,並綁定了一個 navigateToAddItemView
布林值。這個 NavigationLink
的目的地是我們之前設計的 AddItemView。isActive 綁定這個布林值來控制何時觸發導覽。navigateToAddItemView
設為 true,這將觸發 NavigationLink
,進行頁面跳轉。onAppear()
與 UIKit 的 viewWillAppear
類似,它會在 View 即將出現時執行。我們利用這個方法,在頁面顯示時重新取得資料,讓列表的內容是最新的。這樣一來,使用者點擊懸浮按鈕後,就會跳轉到新增項目的頁面。完成操作後,使用者可以透過標題列的返回按鈕回到上一頁,並且列表會自動更新,顯示最新的資料。
今天我們成功地為家用品管理 App 增加一個全新的新增項目功能,從建立新增項目的頁面、加入資料驗證機制、到實作懸浮按鈕以及頁面跳轉,一步步讓我們的 App 更加完整。明天我們要新增讓使用者修改列表項目以及標記物品以使用的功能,今天就先寫到這。我們明天見!